Skip to content

fix(studio-server): stop manual-edits seek wrapper from retaining stale applyManifest closures - #2909

Closed
felipecaldas wants to merge 2 commits into
heygen-com:mainfrom
felipecaldas:fix/studio-timeline-leaks
Closed

fix(studio-server): stop manual-edits seek wrapper from retaining stale applyManifest closures#2909
felipecaldas wants to merge 2 commits into
heygen-com:mainfrom
felipecaldas:fix/studio-timeline-leaks

Conversation

@felipecaldas

Copy link
Copy Markdown
Contributor

Problem

Found via a real, long interactive Studio editing session that grew to a 145,592ms local INP and
eventually crashed the tab ("Render process gone"), well after ruling out environment/RAM causes.
Two DevTools heap snapshots ~82s apart showed +487,208 objects; a retainer trace on the top offender
(PropTween, GSAP's internal tween-property object) rooted at window.__hfStudioManualEditsApply.

wrapFunction/wrapSeekFunctions in manualEditsRenderScript.ts wrap __hf.seek /
__player.renderSeek so studio manual edits (translate/rotate/box-size) get reapplied after every
seek. Each installed wrapper closes directly over that generation's applyManifest — but this whole
runtime script is re-injected fresh on every studio edit. If a wrapper from an earlier generation is
still the active one (isWrapped finds it already marked on the current seek function and skips
re-wrapping — by design, e.g. after a plain re-run with no underlying reassignment), it keeps calling
its own stale applyManifest closure, which retains everything it closed over
(manifestEdits, resolveTarget, every apply* helper) for the rest of the session instead of the
current one. The same risk applies if another seek-wrapping subsystem in this file
(studioPositionSeekReapplyRuntime's installSeekTrap, which independently wraps the same two
functions) leaves an older wrapper reachable.

Fix

Route the wrapper through the __hfStudioManualEditsApply window slot — already kept current on
every re-run — instead of closing over applyManifest directly. A wrapper now always calls live
logic, and a stale generation's applyManifest closure becomes collectible as soon as a newer
generation overwrites the slot, regardless of how long the wrapper function object itself stays
reachable. No change to when wrapping happens or the re-entrancy/reassignment-recovery behavior
already covered by the existing interval-polling test (wrapSeekFunctions re-wraps a genuinely
replaced, unmarked seek function exactly as before).

Verified

  • bun test packages/studio-server/src/helpers/manualEditsRenderScript.test.ts packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts — 28/28 pass, including the
    reassignment-recovery case (external code replaces __hf.seek; interval polling re-wraps it).
  • tsc --noEmit clean for @hyperframes/studio-server.
  • oxlint clean on the changed file.

Related, not duplicated by this PR

Two further leak sources were found via retainer traces during the same investigation but aren't
fixed here — filing a separate issue with the evidence rather than bundling unrelated, less-verified
changes into this PR:

  1. A React Fiber on a hidden <img> (class="hidden"), retained via blink::ThreadState's "Pending
    activities" — looks like asset/thumbnail image handling leaving pending decode state uncleaned.
  2. GSAP's own global TimelineLite (distinct from window.__timelines), reached through nested
    bound_this closures rooted in Studio's own UI code — plausibly its playhead/scrubber, not yet
    isolated to a specific file.

🤖 Generated with Claude Code

miguel-heygen and others added 2 commits July 30, 2026 21:34
…le applyManifest closures

wrapFunction/wrapSeekFunctions wrap __hf.seek/__player.renderSeek to reapply
studio manual edits (translate/rotate/box-size) after every seek. Each
wrapper closed directly over that generation's applyManifest — the whole
runtime script is re-injected on every studio edit, so if a wrapper from an
earlier generation is still active (isWrapped finds it already marked and
skips re-wrapping, or it's left in place by another seek-wrapping subsystem
such as studioPositionSeekReapplyRuntime's installSeekTrap), it kept calling
its own stale applyManifest — and everything that closure retained
(manifestEdits, resolveTarget, every apply* helper) — for the rest of the
session instead of the current one.

Route the wrapper through the existing __hfStudioManualEditsApply window
slot (already kept current on every re-run) instead of the closed-over
reference, so a wrapper always calls live logic and a stale generation's
closure becomes collectible once the slot is overwritten. No change to when
wrapping happens — confirmed via a DevTools retainer trace during a long
Studio editing session that this closure chain was what stayed alive across
repeated re-runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 50db827521d0dbbb915aa59f22efbb3c639c85f8. The live-slot dispatch is behaviorally useful, but I found two blockers and one required regression:

  1. P1 — the heap-retention edge is still present. wrappedSeek is still created inside the same studioManualEditsRenderRuntime invocation as applyManifest. In V8, retaining that wrapper retains the shared function context, including the sibling applyManifest binding and its manifestEdits / resolver / helper graph. Reading the current callback through window.__hfStudioManualEditsApply changes which function executes, but does not sever the old wrapper → old runtime-context edge.

    I reproduced this against the exact built helper with Node/V8 GC: after generation B overwrote the global slot, generation A’s wrapper remained installed and a WeakRef to generation A’s parsed manifest still remained alive after repeated forced collections. Move wrapper construction to a separately scoped serialized factory that only closes over the original seek and window/live slot; another nested factory inside this runtime is not enough. Recheck the retainer path after multiple injections.

  2. P1 — drop the unrelated release commit. This PR includes 33eecd5b9 chore: release v0.7.86, which changes 18 release/version/changelog files. Please rebuild the branch from current origin/main and cherry-pick only the bug-fix commit. That also brings in the five commits currently on main but absent from this branch. The release update should remain separate from this bug fix.

  3. P2 — add the exact two-generation regression. The existing seek test injects one generation only; its reassignment path is still same-generation, so reverting these three calls to direct applyManifest() would pass. Add a test that installs manifest A, retains its marked wrapper, injects manifest B into the same window, invokes the retained wrapper, and proves B is applied. Cover reassignment after B and __player.renderSeek if applicable. The memory claim also needs a V8/browser retainer or GC-focused check because the behavioral assertion alone will not detect the shared-context leak.

git diff --check passes. The targeted local test command could not collect in this review worktree because happy-dom is absent, and this exact head currently exposes only the WIP check on GitHub; no substantive build/test/lint checks are attached yet.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additive review. The existing review already requested changes covering the release commit, the shared-closure-context retention, and the missing two-generation test, and I independently reached the same conclusion on the retention point, so I am not restating those. Three things that review does not cover, one of which changes who is being asked to do what.

First, the release files are not yours

Worth saying plainly because the ask reads differently once you know: this branch contains two commits, and the 18 version/changelog/release files all belong to the second one, chore: release v0.7.86, which you did not write. Your own commit is exactly one file, 26 additions and 3 deletions, confined to the helper. You already did the thing a reviewer would normally have to ask for.

What happened is that the release commit is not reachable from main, so a diff against main attributes its files to your branch. v0.7.86 is already published as the latest release from that commit, while main itself still reads 0.7.85 in the package manifests and has no releases/v0.7.86.md. So the release did get cut and tagged, but that commit never landed on main.

Two consequences:

  • The remedy is a rebase onto current main, not a change in how you scope your work. Rebasing drops the release commit from the diff and leaves your single-file fix.
  • Merging as-is would also add a releases/v0.7.86.md and a 0.7.86 bump to main describing this leak fix, while the published v0.7.86 describes an unrelated set of changes. That is worth sorting out on our side independently of this PR, and it is not something you should have to work around.

Second, and this is the part I would prioritize: the wrapper chain grows one link per injected generation

This is the root cause underneath the retention finding, and I do not think it has been named yet. It explains why a stale wrapper is reachable at all.

There are two independent seek-wrapping runtimes in this file, and each has its own marker property: manualEditsRenderScript.ts:39 uses __hfStudioPositionSeekReapplyWrapped, manualEditsRenderScript.ts:393 uses __hfStudioManualEditsWrapped. Each runtime's isWrapped only recognizes its own. So once the position-seek runtime wraps a seek that the manual-edits runtime already wrapped, the manual-edits runtime no longer recognizes it, and wrapFunction (:709-712) stops taking the early-return branch and wraps again on every subsequent generation.

Measured on the real script strings, injecting the position-seek runtime once (as the compiler does) and the manual-edits runtime N times, then calling __hf.seek(1) exactly once and counting how many wrappers it reaches:

generations= 1  ->  wrappers reached per ONE seek: 1   base seek calls: 1
generations= 2  ->  wrappers reached per ONE seek: 2   base seek calls: 1
generations= 3  ->  wrappers reached per ONE seek: 3   base seek calls: 1
generations= 5  ->  wrappers reached per ONE seek: 5   base seek calls: 1
generations= 8  ->  wrappers reached per ONE seek: 8   base seek calls: 1

Linear and unbounded, one new wrapper per edit, each retaining its own generation's scope. That is the reachable-wrapper end of the retention chain, so it is why the shared-context problem bites rather than being theoretical.

A caveat on reproducing this, because it cost me a wrong reading first: the position-seek runtime early-returns unless the document carries one of the data-hf-studio-path-offset|box-size|rotation="true" markers or data-hf-studio-motion (:41-47), and the compiler only injects it when those are present (packages/producer/src/services/htmlCompiler.ts:1898-1903). With a fixture missing those markers the runtime never installs, only one wrapper is ever created, and the growth is invisible. My first probe did exactly that and showed a flat 1 at every generation count. Any test for this needs those attributes on the fixture, or it will pass while measuring nothing.

The implication for your fix: routing through the live slot is necessary but not sufficient. It is the right half, because it is what lets one long-lived wrapper serve every future generation. The missing half is making the manual-edits isWrapped (:699-700) recognize a seek already wrapped by the sibling runtime, so no second wrapper is ever created. With both halves, one wrapper exists for the life of the page, it always calls current logic through the slot, and generations 2..N retain nothing.

Also worth noting as a consequence rather than a defect: at N generations a single seek performs N full applyManifest passes (:648-661) over every edit. That redundancy predates this PR, since each stale wrapper previously called its own stale copy, so you have not made it worse. Your change makes those N passes idempotent and correct instead of stale, which is a real improvement. Capping the chain removes the redundancy entirely.

Third, the sibling runtime has the same shape and is left as-is

wrapFn at :285-303 calls reapplyAll() directly from the wrapper closure, exactly the pattern you replaced on the manual-edits side, and it retains that runtime's reapplyAll plus everything it closes over. It is less severe in practice because that runtime is injected once at compile time rather than per edit, so it does not multiply per generation. Not asking you to fix it here, and I would not expand this PR's scope to cover it. Flagging it so it is a known sibling rather than something rediscovered later.

What is good here

  • The scoping call is right and I would keep making it. You found three leak sources and shipped one, leaving the hidden-<img> React Fiber and the GSAP global out. That is the correct instinct even though it makes the PR look smaller than the work behind it.
  • :662-680 records why the indirection exists and what it is defending against, including naming the sibling runtime as a same-shaped risk. Rationale that explains a non-obvious construct is what stops the next person reverting it as an unnecessary layer.
  • The ?.() on every slot call is the right defensive shape, since a stale wrapper can outlive a generation that returned early at :416 on an empty manifest, which would leave the slot unset.
  • Evidence quality on the report is well above average for a leak: two snapshots, a delta, and a retainer trace rather than an impression.

Verification

  • Full file read at head 50db82752, not just the diff. Both probes below were run against the real exported script strings, and the tree was restored clean afterward.
  • Retention, measured rather than reasoned. I first built a probe that reported "retained" for both the pre-fix and post-fix shapes, but its sensitivity floor also reported "retained" for a case that must free, so it proved nothing. The cause was that a WeakRef target stays alive for the remainder of the job in which it is created. After adding a macrotask boundary before collection, the floor cases free correctly and the pre-fix and post-fix shapes are still identical. Same conclusion the existing review reached; noting the failure mode because a probe that cannot detect freeing will happily report a leak that is not there, and vice versa.
  • Test sensitivity. Reverting the fix, restoring the direct applyManifest() call at all three sites, leaves all 14 tests in the helper's suite passing. So nothing currently pins this behavior in either direction. Agrees with the two-generation gap already raised.
  • CI has not run, and the reason is on our side. All seven workflows at this head report action_required, which is GitHub holding workflow approval for a first-time contributor rather than anything wrong with your branch. The only reported check is the WIP marketplace one. So there is no CI signal here yet, green or red, and nothing for you to chase. A maintainer needs to authorize the run.
  • Not verified by me: the object-count delta and retainer trace from your snapshots. Those are author-reported, and the code reading is consistent with them.

Verdict: COMMENT (additive to the existing changes-requested; not adding a second gate)
Reasoning: The runtime direction is right and the fix is a genuine correctness improvement, but the retention it claims is not severed yet, and the reason is the chain growing one wrapper per generation from the two runtimes not recognizing each other's markers. Rebase drops 18 files that are not yours.

— Rames Jusso

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Supplemental call-graph finding after the review:

P1 — this is not the runtime that owns the reported long interactive Studio session. The interactive preview path is usePreviewPersistence.tsinstallStudioManualEditSeekReapply() in packages/studio/src/components/editor/manualEdits.ts; those seek wrappers already dispatch through the mutable window.__hfStudioManualEditsApply slot. The helper changed here is the legacy JSON-manifest render helper. Its concrete current call sites inject it into fresh Puppeteer thumbnail pages (which are created and closed per request) or pass it as render-job sidecar input; I found no interactive iframe path that reinjects this helper into the same long-lived window on each Studio edit.

That contradicts the new comment at lines 662–680 and means this patch cannot explain or fix the measured long-session INP/tab crash without an additional call path. Please trace the DevTools retainer root back to the actual interactive installation path and fix that owner, or document/reproduce the exact long-lived same-window path that executes this helper repeatedly.

Two related lifecycle gaps also need coverage if this helper change remains:

  • non-empty → empty manifest returns before replacing/clearing the global slot, so the old apply closure remains active;
  • running the position and manual runtimes together is not idempotent across their distinct markers. An exact-runtime probe showed each manual poll adds another manual/position wrapper pair through the position setter trap; after 10 polls, one seek invoked the live manual apply slot 11 times (the production loop allows 120 polls).

These reinforce the need for a combined-runtime, multi-generation regression rather than the current single-generation tests.

@jrusso1020

Copy link
Copy Markdown
Collaborator

Correcting one framing in my review above, and sharpening the finding as a result.

I had the lifecycle wrong. I described the wrapper multiplying "per injected generation" across an editing session, taking the code comment's "each studio edit / soft reload injects a fresh copy" at face value. That is true of the interactive Studio path, not of this helper. Every consumer of createStudioManualEditsRenderBodyScript is a short-lived page: packages/cli/src/server/studioServer.ts:114 and :427, packages/studio/vite.browser.ts:89, and packages/studio/vite.adapter.ts:251, which are thumbnail pages and render jobs. The long-lived interactive editor uses installStudioManualEditSeekReapply (packages/studio/src/components/editor/manualEdits.ts:232), installed on the preview iframe at packages/studio/src/hooks/usePreviewPersistence.ts:64. So a per-session generation count is the wrong axis for this file, and the credit for catching that is not mine.

The multiplication is real, but it happens inside a single page load, which makes it worse rather than better. The re-wrap poll at manualEditsRenderScript.ts:752-757 runs up to 120 times, and each pass re-wraps because of the marker blindness in my earlier comment. Measured on the real scripts, one seek call, single injected generation:

manual-edits alone     polls= 0  ->  apply calls per ONE seek: 1
manual-edits alone     polls=10  ->  apply calls per ONE seek: 1
manual-edits alone     polls=30  ->  apply calls per ONE seek: 1
both runtimes present  polls= 0  ->  apply calls per ONE seek: 1
both runtimes present  polls= 1  ->  apply calls per ONE seek: 2
both runtimes present  polls=10  ->  apply calls per ONE seek: 11
both runtimes present  polls=30  ->  apply calls per ONE seek: 31

Flat at 1 when the sibling runtime is absent, polls + 1 when it is present. With the production ceiling of 120 that is up to ~121 full applyManifest passes per seek, on a page that seeks once per frame. So the consequence to care about here is render throughput on compiled pages carrying position markers, not a session-long heap climb. Same root cause, same one-line-ish fix, different symptom than I first described.

One thing that argues for your approach. The interactive path already does exactly what you are adding: it sets the slot and calls win.__hfStudioManualEditsApply?.() from its wrapper (manualEdits.ts:84, :234). So this is not a novel indirection, it is bringing the render-page helper in line with the pattern already proven in the editor. Worth citing in the PR body, since it is a stronger argument than the heap trace.

Which leaves the retention evidence pointing at a different file than the one you changed. If the snapshots came from a long editing session, the retained graph was most likely rooted in the interactive path rather than this helper, which is consistent with the ask to trace the retainer root before settling on a fix. Nothing here reduces the value of the change; the marker fix stands on the measurement above on its own.

Verdict unchanged: COMMENT, additive. The measurement is independent of the earlier review's; where we overlap, we agree.

— Rames Jusso

@felipecaldas

Copy link
Copy Markdown
Contributor Author

Closing — this was opened before the fix was actually tested against a real Studio session. Apologies for the noise; will reopen (or open fresh) once verified.

felipecaldas added a commit to felipecaldas/hyperframes that referenced this pull request Aug 30, 2026
15 releases and 154 commits of upstream drift, taken in one merge rather than
letting it compound. Measured cost: 25 of our 114 patched files overlapped
upstream's 674, producing 10 real code hunks across 6 files (plus bun.lock,
regenerated).

Three carried patches are dropped because upstream now owns them:

  - TAB-792 (79e8858). Upstream heygen-com#3535 landed a logically identical
    start-based predicate for the local-vs-root-global timing convention.
    Taken verbatim so the file returns to upstream identity and stops
    conflicting here forever.
  - The heygen-com#3349 cherry-pick (0d1d683). Upstream refactored the same bound
    into `clampNativeMediaVolume`, which `withUnclampedVolume` also uses, so
    the two cannot drift.
  - The studio-server lint *route*. Upstream heygen-com#3393 runs whole-project lint
    first and only falls back to per-file for uncovered HTML — a superset of
    ours, and a better answer to the TAB-780/781 problem. The
    `helpers/projectLint.ts` helper STAYS: agent/providers.ts and
    agent/runtime.ts still import it.

Patches kept, re-sited onto upstream's refactors:

  - PromptPreviewModal moved to its own file upstream and gained a focus trap,
    a dirty-draft close veto and copy-failure state. Our "Create with Agent"
    button and `registryItem` prop are ported onto that version rather than
    keeping our older in-file copy.
  - EditModal's agent-bridge handoff keeps its behaviour and adopts upstream's
    draft clearing. No copy-failure branch: openAgentBridge is a synchronous
    window event, not a clipboard write the browser can refuse.
  - PropertyPanelFlat's caption section composes with upstream's new audio-fx
    fallback.

Verified on this branch: build exit 0; typecheck clean in core, studio and
studio-server; lint 0 errors; core 2598 tests, studio 4604, studio-server 534,
all passing with real summary lines. Fork invariants measured on the built
dist — vendorRoute=3, sameOriginMotionPath=1, jsdelivrGsap=0, jsdelivrAll=0,
__hfStudioManualEditsApply=6 (fork count, not upstream's 3), so TAB-697,
TAB-746 and PR heygen-com#2909 all survived.

Renders are NOT proven frame-stable: 674 upstream files changed and no render
evidence was gathered here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants